Header menu logo FSharp.Analyzers.SDK

Writing an analyzer for both console and editor

With a little orchestration it is possible to easily write two analyzer functions that share a common implementation.

open FSharp.Analyzers.SDK
open FSharp.Analyzers.SDK.ASTCollecting
open FSharp.Compiler.CodeAnalysis
open FSharp.Compiler.Text
open FSharp.Compiler.Syntax

/// This analyzer function will try and detect if any `System.*` open statement was found after any non System open.
/// See https://learn.microsoft.com/en-us/dotnet/fsharp/style-guide/conventions#sort-open-statements-topologically
/// Note that this implementation is not complete and only serves as an illustration.
/// Nested modules are not taken into account.
let private topologicallySortedOpenStatementsAnalyzer
    (sourceText: ISourceText)
    (untypedTree: ParsedInput)
    (checkResults: FSharpCheckFileResults)
    : Async<Message list>
    =
    async {
        let allOpenStatements =
            let allOpenStatements = ResizeArray<string list * range>()

            let (|LongIdentAsString|) (lid: SynLongIdent) =
                lid.LongIdent |> List.map (fun ident -> ident.idText)

            let walker =
                { new SyntaxCollectorBase() with
                    override _.WalkSynModuleSigDecl(_, decl: SynModuleSigDecl) =
                        match decl with
                        | SynModuleSigDecl.Open(
                            target = SynOpenDeclTarget.ModuleOrNamespace(longId = LongIdentAsString value; range = mOpen)) ->
                            allOpenStatements.Add(value, mOpen)
                        | _ -> ()

                    override _.WalkSynModuleDecl(_, decl: SynModuleDecl) =
                        match decl with
                        | SynModuleDecl.Open(
                            target = SynOpenDeclTarget.ModuleOrNamespace(longId = LongIdentAsString value; range = mOpen)) ->
                            allOpenStatements.Add(value, mOpen)
                        | _ -> ()
                }

            ASTCollecting.walkAst walker untypedTree

            allOpenStatements |> Seq.toList

        let isSystemOpenStatement (openStatement: string list, mOpen: range) =
            let isFromBCL () =
                let line = sourceText.GetLineString(mOpen.EndLine - 1)

                match checkResults.GetSymbolUseAtLocation(mOpen.EndLine, mOpen.EndColumn, line, openStatement) with
                | Some symbolUse ->
                    match symbolUse.Symbol.Assembly.FileName with
                    | None -> false
                    | Some assemblyPath ->
                        // This might not be an airtight check
                        assemblyPath.ToLower().Contains "microsoft.netcore.app.ref"
                | _ -> false

            openStatement.[0].StartsWith("System") && isFromBCL ()

        let nonSystemOpens = allOpenStatements |> List.skipWhile isSystemOpenStatement

        return
            nonSystemOpens
            |> List.filter isSystemOpenStatement
            |> List.map (fun (openStatement, mOpen) ->
                let openStatementText = openStatement |> String.concat "."

                {
                    Type = "Unsorted System open statement"
                    Message = $"%s{openStatementText} was found after non System namespaces where opened!"
                    Code = "SOT001"
                    Severity = Severity.Warning
                    Range = mOpen
                    Fixes = []
                }
            )
    }

[<CliAnalyzer "Topologically sorted open statements">]
let cliAnalyzer (ctx: CliContext) : Async<Message list> =
    topologicallySortedOpenStatementsAnalyzer ctx.SourceText ctx.ParseFileResults.ParseTree ctx.CheckFileResults

[<EditorAnalyzer "Topologically sorted open statements">]
let editorAnalyzer (ctx: EditorContext) : Async<Message list> =
    match ctx.CheckFileResults with
    // The editor might not have any check results for a given file. So we don't return any messages.
    | None -> async.Return []
    | Some checkResults ->
        topologicallySortedOpenStatementsAnalyzer ctx.SourceText ctx.ParseFileResults.ParseTree checkResults

Both analyzers will follow the same code path: the console application will always have the required data, while the editor needs to be more careful.
⚠️ Please do not be tempted by calling .Value on the EditorContext 😉.

To enable a wide range of analyzers, both context types give access to very detailed information about the source code.
Among this information is the full untyped abstract syntax tree (AST) and the typed abstract syntax tree (TAST). As you can deduce from the example above, processing these trees is a very common task in an analyzer. But writing your own tree traversal code can be daunting and can also get quite repetitive over many analyzers.
That's why the SDK offers the ASTCollecting and TASTCollecting modules. In there, you'll find facility types and functions to make your analyzers author life easier. For both trees, a type is defined, SyntaxCollectorBase and TypedTreeCollectorBase respectively, with members you can override to have easy access to the tree elements you want to process.
Just pass an instance with your overriden members to the walkAst or walkTast function.

The open-statement analyzer from above uses the AST for it's analysis.
Because we want to process the SynModuleSigDecl and SynModuleDecl elements of the AST, we just override the two appropriate members of the SyntaxCollectorBase type in an object expression and pass the instance to walkAst.
Much simpler and shorter than doing the traversal ourselves.

Previous Next

Multiple items
namespace FSharp

--------------------
namespace Microsoft.FSharp
namespace FSharp.Analyzers
namespace FSharp.Analyzers.SDK
module ASTCollecting from FSharp.Analyzers.SDK
namespace FSharp.Compiler
namespace FSharp.Compiler.CodeAnalysis
namespace FSharp.Compiler.Text
namespace FSharp.Compiler.Syntax
val private topologicallySortedOpenStatementsAnalyzer: sourceText: ISourceText -> untypedTree: ParsedInput -> checkResults: FSharpCheckFileResults -> Async<Message list>
 This analyzer function will try and detect if any `System.*` open statement was found after any non System open.
 See https://learn.microsoft.com/en-us/dotnet/fsharp/style-guide/conventions#sort-open-statements-topologically
 Note that this implementation is not complete and only serves as an illustration.
 Nested modules are not taken into account.
val sourceText: ISourceText
type ISourceText = abstract ContentEquals: sourceText: ISourceText -> bool abstract CopyTo: sourceIndex: int * destination: char array * destinationIndex: int * count: int -> unit abstract GetLastCharacterPosition: unit -> int * int abstract GetLineCount: unit -> int abstract GetLineString: lineIndex: int -> string abstract GetSubTextFromRange: range: range -> string abstract GetSubTextString: start: int * length: int -> string abstract SubTextEquals: target: string * startIndex: int -> bool abstract Item: index: int -> char with get abstract Length: int
val untypedTree: ParsedInput
Multiple items
module ParsedInput from FSharp.Compiler.Syntax

--------------------
type ParsedInput = | ImplFile of ParsedImplFileInput | SigFile of ParsedSigFileInput member FileName: string member Identifiers: Set<string> member IsImplFile: bool member IsSigFile: bool member QualifiedName: QualifiedNameOfFile member Range: range member ScopedPragmas: ScopedPragma list
val checkResults: FSharpCheckFileResults
type FSharpCheckFileResults = member GenerateSignature: ?pageWidth: int -> ISourceText option member GetAllUsesOfAllSymbolsInFile: ?cancellationToken: CancellationToken -> FSharpSymbolUse seq member GetDeclarationListInfo: parsedFileResults: FSharpParseFileResults option * line: int * lineText: string * partialName: PartialLongName * ?getAllEntities: (unit -> AssemblySymbol list) * ?completionContextAtPos: (pos * CompletionContext option) -> DeclarationListInfo member GetDeclarationListSymbols: parsedFileResults: FSharpParseFileResults option * line: int * lineText: string * partialName: PartialLongName * ?getAllEntities: (unit -> AssemblySymbol list) -> FSharpSymbolUse list list member GetDeclarationLocation: line: int * colAtEndOfNames: int * lineText: string * names: string list * ?preferFlag: bool -> FindDeclResult member GetDescription: symbol: FSharpSymbol * inst: (FSharpGenericParameter * FSharpType) list * displayFullName: bool * range: range -> ToolTipText member GetDisplayContextForPos: cursorPos: pos -> FSharpDisplayContext option member GetF1Keyword: line: int * colAtEndOfNames: int * lineText: string * names: string list -> string option member GetFormatSpecifierLocationsAndArity: unit -> (range * int) array member GetKeywordTooltip: names: string list -> ToolTipText ...
Multiple items
type Async = static member AsBeginEnd: computation: ('Arg -> Async<'T>) -> ('Arg * AsyncCallback * obj -> IAsyncResult) * (IAsyncResult -> 'T) * (IAsyncResult -> unit) static member AwaitEvent: event: IEvent<'Del,'T> * ?cancelAction: (unit -> unit) -> Async<'T> (requires delegate and 'Del :> Delegate) static member AwaitIAsyncResult: iar: IAsyncResult * ?millisecondsTimeout: int -> Async<bool> static member AwaitTask: task: Task<'T> -> Async<'T> + 1 overload static member AwaitWaitHandle: waitHandle: WaitHandle * ?millisecondsTimeout: int -> Async<bool> static member CancelDefaultToken: unit -> unit static member Catch: computation: Async<'T> -> Async<Choice<'T,exn>> static member Choice: computations: Async<'T option> seq -> Async<'T option> static member FromBeginEnd: beginAction: (AsyncCallback * obj -> IAsyncResult) * endAction: (IAsyncResult -> 'T) * ?cancelAction: (unit -> unit) -> Async<'T> + 3 overloads static member FromContinuations: callback: (('T -> unit) * (exn -> unit) * (OperationCanceledException -> unit) -> unit) -> Async<'T> ...

--------------------
type Async<'T>
type Message = { Type: string Message: string Code: string Severity: Severity Range: range Fixes: Fix list } member Equals: Message * IEqualityComparer -> bool
type 'T list = List<'T>
val async: AsyncBuilder
val allOpenStatements: (string list * range) list
val allOpenStatements: ResizeArray<string list * range>
type ResizeArray<'T> = System.Collections.Generic.List<'T>
Multiple items
val string: value: 'T -> string

--------------------
type string = System.String
type range = Range
val lid: SynLongIdent
Multiple items
union case SynLongIdent.SynLongIdent: id: LongIdent * dotRanges: range list * trivia: FSharp.Compiler.SyntaxTrivia.IdentTrivia option list -> SynLongIdent

--------------------
type SynLongIdent = | SynLongIdent of id: LongIdent * dotRanges: range list * trivia: IdentTrivia option list member Dots: range list member IdentsWithTrivia: SynIdent list member LongIdent: LongIdent member Range: range member RangeWithoutAnyExtraDot: range member ThereIsAnExtraDotAtTheEnd: bool member Trivia: IdentTrivia list
property SynLongIdent.LongIdent: LongIdent with get
Multiple items
module List from Microsoft.FSharp.Collections

--------------------
type List<'T> = | op_Nil | op_ColonColon of Head: 'T * Tail: 'T list interface IReadOnlyList<'T> interface IReadOnlyCollection<'T> interface IEnumerable interface IEnumerable<'T> member GetReverseIndex: rank: int * offset: int -> int member GetSlice: startIndex: int option * endIndex: int option -> 'T list static member Cons: head: 'T * tail: 'T list -> 'T list member Head: 'T member IsEmpty: bool member Item: index: int -> 'T with get ...
val map: mapping: ('T -> 'U) -> list: 'T list -> 'U list
val ident: Ident
property Ident.idText: string with get
val walker: SyntaxCollectorBase
Multiple items
type SyntaxCollectorBase = new: unit -> SyntaxCollectorBase abstract WalkAttribute: path: SyntaxVisitorPath * attribute: SynAttribute -> unit + 1 overload abstract WalkBinding: path: SyntaxVisitorPath * binding: SynBinding -> unit + 1 overload abstract WalkClause: path: SyntaxVisitorPath * matchClause: SynMatchClause -> unit + 1 overload abstract WalkComponentInfo: path: SyntaxVisitorPath * componentInfo: SynComponentInfo -> unit + 1 overload abstract WalkEnumCase: path: SyntaxVisitorPath * enumCase: SynEnumCase -> unit + 1 overload abstract WalkExpr: path: SyntaxVisitorPath * expr: SynExpr -> unit + 1 overload abstract WalkField: path: SyntaxVisitorPath * field: SynField -> unit + 1 overload abstract WalkInterfaceImpl: path: SyntaxVisitorPath * interfaceImpl: SynInterfaceImpl -> unit + 1 overload abstract WalkInterpolatedStringPart: path: SyntaxVisitorPath * interpolatedStringPart: SynInterpolatedStringPart -> unit + 1 overload ...
<summary> The members of this type are called by walkAst. By overwriting the members for various syntax elements, a custom operation can be executed for them. </summary>

--------------------
new: unit -> SyntaxCollectorBase
val decl: SynModuleSigDecl
type SynModuleSigDecl = | ModuleAbbrev of ident: Ident * longId: LongIdent * range: range | NestedModule of moduleInfo: SynComponentInfo * isRecursive: bool * moduleDecls: SynModuleSigDecl list * range: range * trivia: SynModuleSigDeclNestedModuleTrivia | Val of valSig: SynValSig * range: range | Types of types: SynTypeDefnSig list * range: range | Exception of exnSig: SynExceptionSig * range: range | Open of target: SynOpenDeclTarget * range: range | HashDirective of hashDirective: ParsedHashDirective * range: range | NamespaceFragment of SynModuleOrNamespaceSig member IsException: bool member IsHashDirective: bool member IsModuleAbbrev: bool member IsNamespaceFragment: bool member IsNestedModule: bool member IsOpen: bool member IsTypes: bool member IsVal: bool member Range: range
union case SynModuleSigDecl.Open: target: SynOpenDeclTarget * range: range -> SynModuleSigDecl
type SynOpenDeclTarget = | ModuleOrNamespace of longId: SynLongIdent * range: range | Type of typeName: SynType * range: range member IsModuleOrNamespace: bool member IsType: bool member Range: range
union case SynOpenDeclTarget.ModuleOrNamespace: longId: SynLongIdent * range: range -> SynOpenDeclTarget
active recognizer LongIdentAsString: SynLongIdent -> string list
val value: string list
val mOpen: range
System.Collections.Generic.List.Add(item: string list * range) : unit
val decl: SynModuleDecl
type SynModuleDecl = | ModuleAbbrev of ident: Ident * longId: LongIdent * range: range | NestedModule of moduleInfo: SynComponentInfo * isRecursive: bool * decls: SynModuleDecl list * isContinuing: bool * range: range * trivia: SynModuleDeclNestedModuleTrivia | Let of isRecursive: bool * bindings: SynBinding list * range: range | Expr of expr: SynExpr * range: range | Types of typeDefns: SynTypeDefn list * range: range | Exception of exnDefn: SynExceptionDefn * range: range | Open of target: SynOpenDeclTarget * range: range | Attributes of attributes: SynAttributes * range: range | HashDirective of hashDirective: ParsedHashDirective * range: range | NamespaceFragment of fragment: SynModuleOrNamespace member IsAttributes: bool member IsException: bool member IsExpr: bool member IsHashDirective: bool member IsLet: bool member IsModuleAbbrev: bool member IsNamespaceFragment: bool member IsNestedModule: bool member IsOpen: bool member IsTypes: bool ...
union case SynModuleDecl.Open: target: SynOpenDeclTarget * range: range -> SynModuleDecl
val walkAst: walker: SyntaxCollectorBase -> input: ParsedInput -> unit
<summary> Traverses the whole AST and calls the appropriate members of the given SyntaxCollectorBase to process the syntax elements. </summary>
module Seq from Microsoft.FSharp.Collections
val toList: source: 'T seq -> 'T list
val isSystemOpenStatement: openStatement: string list * mOpen: range -> bool
val openStatement: string list
val isFromBCL: unit -> bool
val line: string
abstract ISourceText.GetLineString: lineIndex: int -> string
property Range.EndLine: int with get
member FSharpCheckFileResults.GetSymbolUseAtLocation: line: int * colAtEndOfNames: int * lineText: string * names: string list -> FSharpSymbolUse option
property Range.EndColumn: int with get
union case Option.Some: Value: 'T -> Option<'T>
val symbolUse: FSharpSymbolUse
property FSharpSymbolUse.Symbol: FSharp.Compiler.Symbols.FSharpSymbol with get
property FSharp.Compiler.Symbols.FSharpSymbol.Assembly: FSharp.Compiler.Symbols.FSharpAssembly with get
property FSharp.Compiler.Symbols.FSharpAssembly.FileName: string option with get
union case Option.None: Option<'T>
val assemblyPath: string
System.String.ToLower() : string
System.String.ToLower(culture: System.Globalization.CultureInfo) : string
val nonSystemOpens: (string list * range) list
val skipWhile: predicate: ('T -> bool) -> list: 'T list -> 'T list
val filter: predicate: ('T -> bool) -> list: 'T list -> 'T list
val openStatementText: string
module String from Microsoft.FSharp.Core
val concat: sep: string -> strings: string seq -> string
type Severity = | Info | Hint | Warning | Error member Equals: Severity * IEqualityComparer -> bool member IsError: bool member IsHint: bool member IsInfo: bool member IsWarning: bool
union case Severity.Warning: Severity
Multiple items
module Range from FSharp.Compiler.Text

--------------------
[<Struct>] type Range = member End: pos member EndColumn: int member EndLine: int member EndRange: range member FileName: string member IsSynthetic: bool member Start: pos member StartColumn: int member StartLine: int member StartRange: range ...
Multiple items
type CliAnalyzerAttribute = inherit AnalyzerAttribute new: [<Optional; DefaultParameterValue (("Analyzer" :> obj))>] name: string * [<Optional; DefaultParameterValue (("" :> obj))>] shortDescription: string * [<Optional; DefaultParameterValue (("" :> obj))>] helpUri: string -> CliAnalyzerAttribute member Name: string
<summary> Marks an analyzer for scanning during the console application run. </summary>

--------------------
new: [<System.Runtime.InteropServices.Optional; System.Runtime.InteropServices.DefaultParameterValue (("Analyzer" :> obj))>] name: string * [<System.Runtime.InteropServices.Optional; System.Runtime.InteropServices.DefaultParameterValue (("" :> obj))>] shortDescription: string * [<System.Runtime.InteropServices.Optional; System.Runtime.InteropServices.DefaultParameterValue (("" :> obj))>] helpUri: string -> CliAnalyzerAttribute
val cliAnalyzer: ctx: CliContext -> Async<Message list>
val ctx: CliContext
type CliContext = { FileName: string SourceText: ISourceText ParseFileResults: FSharpParseFileResults CheckFileResults: FSharpCheckFileResults TypedTree: FSharpImplementationFileContents option CheckProjectResults: FSharpCheckProjectResults } interface Context member Equals: CliContext * IEqualityComparer -> bool member GetAllEntities: publicOnly: bool -> AssemblySymbol list member GetAllSymbolUsesOfFile: unit -> FSharpSymbolUse seq member GetAllSymbolUsesOfProject: unit -> FSharpSymbolUse array
<summary> All the relevant compiler information for a given file. Contains the source text, untyped and typed tree information. </summary>
CliContext.SourceText: ISourceText
<summary> Source of the current file. See &lt;a href="https://fsharp.github.io/fsharp-compiler-docs/reference/fsharp-compiler-text-isourcetext.html"&gt;ISourceText Type&lt;/a&gt; </summary>
CliContext.ParseFileResults: FSharpParseFileResults
<summary> Represents the results of parsing an F# file and a set of analysis operations based on the parse tree alone. See &lt;a href="https://fsharp.github.io/fsharp-compiler-docs/reference/fsharp-compiler-codeanalysis-fsharpparsefileresults.html"&gt;FSharpParseFileResults Type&lt;/a&gt; </summary>
property FSharpParseFileResults.ParseTree: ParsedInput with get
CliContext.CheckFileResults: FSharpCheckFileResults
<summary> A handle to the results of CheckFileInProject. See &lt;a href="https://fsharp.github.io/fsharp-compiler-docs/reference/fsharp-compiler-codeanalysis-fsharpcheckfileresults.html"&gt;FSharpCheckFileResults Type&lt;/a&gt; </summary>
Multiple items
type EditorAnalyzerAttribute = inherit AnalyzerAttribute new: [<Optional; DefaultParameterValue (("Analyzer" :> obj))>] name: string * [<Optional; DefaultParameterValue (("" :> obj))>] shortDescription: string * [<Optional; DefaultParameterValue (("" :> obj))>] helpUri: string -> EditorAnalyzerAttribute member Name: string
<summary> Marks an analyzer for scanning during IDE integration. </summary>

--------------------
new: [<System.Runtime.InteropServices.Optional; System.Runtime.InteropServices.DefaultParameterValue (("Analyzer" :> obj))>] name: string * [<System.Runtime.InteropServices.Optional; System.Runtime.InteropServices.DefaultParameterValue (("" :> obj))>] shortDescription: string * [<System.Runtime.InteropServices.Optional; System.Runtime.InteropServices.DefaultParameterValue (("" :> obj))>] helpUri: string -> EditorAnalyzerAttribute
val editorAnalyzer: ctx: EditorContext -> Async<Message list>
val ctx: EditorContext
type EditorContext = { FileName: string SourceText: ISourceText ParseFileResults: FSharpParseFileResults CheckFileResults: FSharpCheckFileResults option TypedTree: FSharpImplementationFileContents option CheckProjectResults: FSharpCheckProjectResults option } interface Context member Equals: EditorContext * IEqualityComparer -> bool member GetAllEntities: publicOnly: bool -> AssemblySymbol list member GetAllSymbolUsesOfFile: unit -> FSharpSymbolUse seq member GetAllSymbolUsesOfProject: unit -> FSharpSymbolUse array
<summary> Optional compiler information for a given file. The available contents is controlled based on what information the IDE has available. </summary>
EditorContext.CheckFileResults: FSharpCheckFileResults option
<summary> A handle to the results of CheckFileInProject. See &lt;a href="https://fsharp.github.io/fsharp-compiler-docs/reference/fsharp-compiler-codeanalysis-fsharpcheckfileresults.html"&gt;FSharpCheckFileResults Type&lt;/a&gt; </summary>
member AsyncBuilder.Return: value: 'T -> Async<'T>
EditorContext.SourceText: ISourceText
<summary> Source of the current file. See &lt;a href="https://fsharp.github.io/fsharp-compiler-docs/reference/fsharp-compiler-text-isourcetext.html"&gt;ISourceText Type&lt;/a&gt; </summary>
EditorContext.ParseFileResults: FSharpParseFileResults
<summary> Represents the results of parsing an F# file and a set of analysis operations based on the parse tree alone. See &lt;a href="https://fsharp.github.io/fsharp-compiler-docs/reference/fsharp-compiler-codeanalysis-fsharpparsefileresults.html"&gt;FSharpParseFileResults Type&lt;/a&gt; </summary>

Type something to start searching.